package com.cosmo.integration.web;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.StringReader;
import java.io.Writer;
import java.nio.charset.Charset;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;

import org.apache.commons.lang.StringUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.http.Header;
import org.joda.time.DateTime;
import org.joda.time.format.DateTimeFormatter;
import org.joda.time.format.ISODateTimeFormat;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.mvc.Controller;
import org.springframework.xml.xpath.XPathExpression;
import org.springframework.xml.xpath.XPathExpressionFactory;
import org.w3c.dom.Document;
import org.xml.sax.InputSource;

import com.cosmo.integration.service.AtomService;

public class AtomController implements Controller {

	private final Log logger = LogFactory.getLog(getClass());

	// its advised to pre-lookup the info to avoid delay in constructing each time
	Charset utf8 = Charset.forName("UTF-8");

	// cosmo is iso.dateTimeNoMillis format, DateTimeFormat.forPattern("yyyy-MM-dd'T'HH:mm:ssZZ");
	DateTimeFormatter dateFormatter = ISODateTimeFormat.dateTimeNoMillis();

	// pre-define some xml manipulations
    private final DocumentBuilderFactory documentBuilderFactory;
	private final XPathExpression selectCollectionName;
	private final XPathExpression selectCollectionId;
	private final AtomService calendarAtomService;

	public AtomController(AtomService calendarAtomService, Map<String, String> namespaces) {
		this.calendarAtomService = calendarAtomService;
	    this.documentBuilderFactory = DocumentBuilderFactory.newInstance();
	    this.documentBuilderFactory.setNamespaceAware(true);
	    // match the collection name and uuid from...
	    // <?xml version='1.0' encoding='UTF8'?><div class="collection"><span class="name">bijy</span><span class
		// ="uuid">30403f40-6566-11de-91aa-cec1f009ab56</span></div>
	    this.selectCollectionName = XPathExpressionFactory.createXPathExpression("//span[@class='name']", namespaces);
	    this.selectCollectionId = XPathExpressionFactory.createXPathExpression("//span[@class='uuid']", namespaces);
	}

	// maps requests to 'understandable' methods, call cosmo and return its results
	// we don't link the streams because we want to 'understand' the command so we can intercept the calls for further processing
	// it would however be quicker piping streams and use less memory
    public ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception {
		// we get the url, which is a full path except the query parameters
    	// TODO is this the raw-encoded constructed request?
    	String url = request.getRequestURL().toString();
    	// raw-encoded query string
		String queryString = request.getQueryString();

		// uri encoding is broken in java, httpclient etc...http://www.lunatech-research.com/archives/2009/02/03/what-every-web-developer-must-know-about-url-encoding
		// we can avoid the problem if we don't use a 'path' (eg where jsession goes), and stick to encoding only the query params
		// but cosmo uses the path to place data - eg the collection ids, so we need to ensure its decoded
		// we hope java understands the path and decodes it correctly - we know of the pitfalls in constructing an uri (see cosmoService.createURI)
		// we may not be able to get the original uri because its already been interpreted
		// if we can get the raw request string, try this code to correctly decode it
		// - where URLUtils is replaced with some httpclient 4 URI.create code
		//URI uri = new URI("http://example.com/a%2Fb%3Fc");
		//for(String pathSegment : uri.getRawPath().split("/"))
		  //System.out.println(URLUtils.decodePathSegment(pathSegment));
		// TODO does this work with the charset given in the request/encoding filter?
		String urlDecoded = request.getPathInfo();

		Map<String, String> paramsDecoded = getDecodedParameters(request);
		logger.info("Handling " + request.getMethod() + " request for url " + url + " params: " + queryString);
		logger.info("decoded url: " + urlDecoded + ", decodedParamsMap: " + paramsDecoded);

		// forward the request to the cosmo server
		Writer writer = null;
		try {

			// the request is forwarded, and we simply translate any responses base uri and pass back
			// the base uri is the key address used by the front end in manipulating info in the system
			// (as shown in the docs) - see http://chandlerproject.org/Projects/CosmoFeedServiceSpec
			HttpResponseResult result = proxyRequest(urlDecoded, paramsDecoded, request.getMethod(), request);

			// mimic the headers - since the js might rely on it
			// we set the encoding header (utf-8) so that the writer conforms to utf-8 of spring's character filer
			// having the calendar as a separate servlet would solve that
			// setting methods here, such as content type, does not conflict with writing to the stream
			// but this must be called before a getWriter is called
			// http://java.sun.com/products/servlet/2.3/javadoc/javax/servlet/ServletResponse.html#getWriter()
			setResponseHeaders(response, result);

			// if there is something to write...
			if (result.getResponseBody() != null) {
				// we get a handle on the stream so we can write the payload (or body) in utf-8 (since all our output is utf-8)
				// if we use convenient methods, print(), then the default system encoding is used - whatever that may be
				// there are alternative methods in commons etc, see below - however, we are fine with what we have determined
				// NB this is not to say the payload, or body, can't be some other encoding!
				writer = getWriter(response);
	
				// write the response here
				// if we wanted an efficiency we would link the streams but headers might be tricky to set
				writer.write(result.getResponseBody());
	
				// flush and close
				writer.flush();
			}

		} catch (IOException e) {
			// we'll log this, and try to close the connection
			//logger.error("failed to write to the response BufferedWriter", e);
			throw new RuntimeException("failed to write to the response BufferedWriter", e);
		} finally {
			try {
				if (writer != null) {
					writer.close();
				}
			} catch (IOException e) {
				// we continue quietly - in other words, this is treated as a recoverable error by simply continuing
				// and we shouldn't throw an exception in finally blocks anyway
				logger.error("failed to close the BufferedWriter after writing the response", e);
			}
		}

		return null;
    }

    protected HttpResponseResult proxyRequest(String urlDecoded, Map<String, String> paramsDecoded, String method, HttpServletRequest request) throws IOException {

    	// we check the whole process: that the js request is in utf-8 format
    	// that cosmo accepts utf-8 (no filter etc) and that cosmo responds with utf-8 header and the js accepts this

    	// *********************
    	// cosmo ATOM described - http://chandlerproject.org/Developers/WebHome#Client%20Access%20to%20Chandler%20Server
    	// this proxy largely handles only the requests from the ui - and is not a full extraction from the spec

    	// CALENDAR
    	// start up 'calling' order
    	// /user/<user> - gets the collection names and preferences link
    	// /user/<user>/subscriptions - doesn't get a lot so far
    	// /collection/.../details - shows the access to the events through various means
    	// /user/<user>/preferences - doesn't get a lot so far
    	// /collection/.../dashboard/eim-json - gets the current collection's events

    	// /mc/collection - application/eim+xml
		// /dav/collection - text/xml
		// /webcal/collection - text/calendar
		// /pim/collection - text/html
		// /collection/../ticket - application/atom+xml

    	// if we are a post we get the body now, because if we try to multiple times, the 'reader' will return blank
    	String postBody = null;
    	if (StringUtils.equalsIgnoreCase(method, "post"))
    		postBody = getRequestBody(request);

    	// /user/<user>/subscriptions - seems to show nothing
		// /user/<user>/preferences - seems to show nothing
		// /user/<user>
		//		as a get - get the collections for a user
		//		as a post - create a new collection
    	if(urlDecoded.endsWith("/subscriptions")) {
			return calendarAtomService.getSubscriptions(getUsernameFromUrl(urlDecoded));
		} else if (urlDecoded.endsWith("/preferences")) {
			return calendarAtomService.getPreferences(getUsernameFromUrl(urlDecoded));
		} else if (urlDecoded.contains("/user/")) {
			if (StringUtils.equalsIgnoreCase(method, "get"))
				return calendarAtomService.getWorkspaces(getUsernameFromUrl(urlDecoded));
			if (StringUtils.equalsIgnoreCase(method, "post"))
				// we don't link the streams because we want to 'understand' the command in case of further processing
				// it would however be quicker piping streams and use less memory
				return calendarAtomService.createCollection(getUsernameFromUrl(urlDecoded), getCollectionNameFromUserFeedRequestSubmission(postBody), getCollectionIdFromUserFeedRequestSubmission(postBody));

		// /collection/{uid}/{projection}?/{format}?
		// /collection/.../details - gets access details for the events through various means
		// /collection/.../dashboard/eim-json - gets the current collection's events
		// /collection/.../full/eim-json
		//		as a get - get events in range for collection
		//		as a delete - remove the collection
		//		as a post - create an event
		//		as a put - update a collection name
		} else if (urlDecoded.endsWith("/details")) {
			return calendarAtomService.getDetailsForCollection(getCollectionIdFromCollectionFeedRequestUrl(urlDecoded));
		} else if (urlDecoded.contains("/dashboard/")) {
			return calendarAtomService.getDashboardForCollection(getCollectionIdFromCollectionFeedRequestUrl(urlDecoded));
		} else if (urlDecoded.contains("/collection/")) {
			if (StringUtils.equalsIgnoreCase(method, "get"))
				return calendarAtomService.getEventsInPeriod(getCollectionIdFromCollectionFeedRequestUrl(urlDecoded), getStartDateFromQuery(paramsDecoded), getEndDateFromQuery(paramsDecoded));
			if (StringUtils.equalsIgnoreCase(method, "post"))
				return calendarAtomService.createEvent(getCollectionIdFromCollectionFeedRequestUrl(urlDecoded), getEventFromCollectionFeedRequestSubmission(postBody), true);
			if (StringUtils.equalsIgnoreCase(method, "delete"))
				return calendarAtomService.deleteCollection(getCollectionIdFromCollectionFeedRequestUrl(urlDecoded));
			if (StringUtils.equalsIgnoreCase(method, "put"))
				return calendarAtomService.updateCollection(getCollectionNameFromUserFeedRequestSubmission(postBody), getCollectionIdFromCollectionFeedRequestUrl(urlDecoded));

		// /item/.../full/eim-json
		// 		as a get - get an event - not used in the ui currently 
		//		as a put - update an event
		//		as a delete - delete an event
		} else if (urlDecoded.contains("/item/")) {
			if (StringUtils.equalsIgnoreCase(method, "get"))
				return calendarAtomService.getEvent(getItemIdFromItemFeedRequestUrl(urlDecoded), true);
			if (StringUtils.equalsIgnoreCase(method, "put"))
				return calendarAtomService.updateEvent(getItemIdFromItemFeedRequestUrl(urlDecoded), getRequestBody(request), true);
			if (StringUtils.equalsIgnoreCase(method, "delete"))
				return calendarAtomService.deleteEvent(getItemIdFromItemFeedRequestUrl(urlDecoded), getEventIdFromQuery(paramsDecoded), true);

		// /expanded/
		//		as a get - get the re-occurances of an event in a period, or the event itself
		} else if (urlDecoded.contains("/expanded/")) {
			return calendarAtomService.getExpandedEventInPeriod(getItemIdFromExpandedFeedRequestUrl(urlDecoded), getStartDateFromQuery(paramsDecoded), getEndDateFromQuery(paramsDecoded));
		}

    	return null;
	}

    protected Map<String, String> getDecodedParameters(HttpServletRequest request) {
    	Enumeration en = request.getParameterNames();
    	Map<String, String> params = new HashMap<String, String>();
    	while (en.hasMoreElements()) {
            String paramName = (String) en.nextElement();
            String paramValue = request.getParameter(paramName);
            params.put(paramName, paramValue);
        }
    	return params;
    }

    protected String getUsernameFromUrl(String url) {
    	// get what's after /<user>/
    	// if there is another '/' then there is more to the url, chop out the username
    	String toReturn = StringUtils.substringAfter(url, "/user/");
    	if (StringUtils.contains(toReturn, '/'))
    		toReturn = StringUtils.substringBefore(toReturn, "/");
        logger.info("UsernameFromUrl: " + toReturn);
		return toReturn;
	}
    protected String getCollectionIdFromCollectionFeedRequestUrl(String url) {
		String toReturn = StringUtils.substringBetween(url, "/collection/", "/");
        logger.info("CollectionIdFromCollectionFeedRequestUrl: " + toReturn);
		return toReturn;
	}
	protected String getCollectionIdFromItemFeedRequestUrl(String url) {
		String toReturn = StringUtils.substringBetween(url, "/item/", "/");
        logger.info("CollectionIdFromItemFeedRequestUrl: " + toReturn);
		return toReturn;
	}
	protected String getItemIdFromItemFeedRequestUrl(String url) {
		String toReturn = StringUtils.substringBetween(url, "/item/", "/");
        logger.info("ItemIdFromItemFeedRequestUrl: " + toReturn);
		return toReturn;
	}
	protected String getItemIdFromExpandedFeedRequestUrl(String url) {
		String toReturn = StringUtils.substringBetween(url, "/expanded/", "/");
        logger.info("ItemIdFromExpandedFeedRequestUrl: " + toReturn);
		return toReturn;
	}
	protected String getEventIdFromQuery(Map<String, String> params) {
		String toReturn = params.get("uuid");
        logger.info("EventIdFromRequestUrl: " + toReturn);
		return toReturn;
	}
	protected String getCollectionNameFromUserFeedRequestSubmission(String body) {
		Document doc = getDocumentFromString(body);
		String collectionName = selectCollectionName.evaluateAsString(doc);
		logger.info("CollectionNameFromUserFeedRequestSubmission: " + collectionName);
		return collectionName;
	}
	protected String getCollectionIdFromUserFeedRequestSubmission(String body) {
		Document doc = getDocumentFromString(body);
		String collectionId = selectCollectionId.evaluateAsString(doc);
		logger.info("CollectionIdFromUserFeedRequestSubmission: " + collectionId);
		return collectionId;
	}
	protected String getEventFromCollectionFeedRequestSubmission(String body) {
		logger.info("EventFromCollectionFeedRequestSubmission: " + body);
		return body;
	}
	protected DateTime getStartDateFromQuery(Map<String, String> params) {
		String date = params.get("start");
		return convertFromCosmoDate(date);
	}
	protected DateTime getEndDateFromQuery(Map<String, String> params) {
		String date = params.get("end");
		return convertFromCosmoDate(date);
	}
	protected DateTime convertFromCosmoDate(String date) {
		DateTime dateTime = dateFormatter.parseDateTime(date);
		return dateTime;
		// since the encoding problems are fixed in httpclient 4, we don't need special handling
		/*
		// remove the space, but add a '+' if there isn't one
		// yyyy-MM-dd'T'HH:mm:ss ZZ
		if ("-".equals(StringUtils.indexOf(date, "-", 22)))
			date = StringUtils.replaceChars(date, " ", "");
		else
			date = StringUtils.replaceChars(date, ' ', '+');
		DateTime dateTime = dateFormatter.parseDateTime(date);
		return dateTime;
		*/
	}
	protected Document getDocumentFromString(String responseAsString) {
        try {
            DocumentBuilder builder = documentBuilderFactory.newDocumentBuilder();
            return builder.parse(new InputSource(new StringReader(responseAsString)));
        } catch (Exception e) {
            throw new RuntimeException("Conversion of response failed, invalid response?", e);
        }
    }
	protected String getRequestBody(HttpServletRequest request) {

		String response = null;
		BufferedReader in = null;
		try {
			// get the stream of bytes (to avoid a system default character encoding)
			// getBodyAsString uses the incoming charset, but may not be specified, and could be too long a response for a string
			String charset = request.getCharacterEncoding();
			// decode - we assume utf-8 (even though spec says iso-8859-1 - we'd rather keep everything utf-8)
			if (StringUtils.isEmpty(charset))
				charset = "UTF-8";
			in = new BufferedReader(new InputStreamReader(request.getInputStream(), charset));
			// FIXME not sure why we read a line at a time, because reading x bytes is surely better?
			String inputLine = "";
	        StringBuilder responseBody = new StringBuilder();
	        while ((inputLine = in.readLine()) != null) {
	        	// because we read a line, we need to put a line back in
	        	// NB but this needs to be the SAME line terminator as the stream, not our local system
	        	// for cosmo, this is '\n'
	        	responseBody.append(inputLine).append('\n'); // System.getProperty("line.separator")
	        }
	        response = responseBody.toString();

		} catch (IOException e) {
	        throw new RuntimeException("Gathering of request body failed: ", e);
	    } finally {
	    	if (in != null) {
	    		try {
	    			in.close();
	    		} catch (IOException e) {
	    	        logger.error("Gathering of request body failed to close: ", e);
	    	    }
	    	}
	    }

		return response;
	}

	// this is the response back to our gui
	protected void setResponseHeaders(HttpServletResponse response, HttpResponseResult result) {

		// set the headers
		for (Iterator iterator = result.getHeaders().iterator(); iterator.hasNext();) {
			Header header = (Header) iterator.next();
			response.setHeader(header.getName(), header.getValue());
		}

		response.setStatus(result.getStatusLine().getStatusCode());

		// this needs to be before any stream or getWriter is used
		// the header specifies the encoding for transmitting the body, when it happens
		// this method is equivalent to placing charset in the setContentType
		// so it overrides whatever encoding is there already
		response.setCharacterEncoding("UTF-8");
	}
	protected Writer getWriter(HttpServletResponse response) {
		// (NB see encodings.txt, and writeAll below, and see IOUtils of apache commons)
		// we need to ensure our string writes in utf-8
		// java strings themselves don't have an encoding - just 16bit characters (matching utf-16)
		// when the string gets written as bytes, it has to conform to an encoding
		// so we write a string to a stream which can convert it to utf-8 encoding
		// the outputstream would write each item directly, which is costly
		// so we use a buffer which determines when it should write it out
		BufferedWriter out = null;
		try {
			out = new BufferedWriter(new OutputStreamWriter(response.getOutputStream(), "UTF-8"));
		} catch (IOException e) {
			throw new RuntimeException("", e);
			//logger.error("failed to open a BufferedWriter to write to the response", e);
		}
		return out;
	}
	// if we collected the response in a string we could write it all out in writeAll
	// but this is less efficient since we potentially end up storing the whole buffer first
	/*
	protected void writeAll(HttpServletResponse response, String str) {
		// if we didn't want to specify an encoding - and rely on the jvm default, we could use:
		// response.getWriter().print(encode(calendarAtomService.getSubscriptions("clients")));
		// (writers should also flush the stream at the end)
		// but print() takes whatever argument, makes a string and uses it with the os default encoding
		// so we need to get to the stream itself to write the encoding we want
		// an outputstreamwriter is a bridge between bytes(OutputStream's) and characters(Writer's)
		// it acts as a writer, converting characters to a byte stream with a given encoding
		// then it writes the encoded bytes to the stream - but each call is expensive
		// so we wrap a bufferwriter round it, and write the buffer out all at once
		BufferedWriter out = null;
		try {
			out = new BufferedWriter(new OutputStreamWriter(response.getOutputStream(), "UTF-8"));
			// this can be called multiple times, bufferedwriter flushes the buffer when it gets full
			out.write(str);
			out.flush();
		} catch (IOException e) {
		} finally {
			try {
				if (out != null) {
					out.flush();
					out.close();
				}
			} catch (IOException e) {
				// we can't close it
				logger.error("failed to close the BufferedWriter");
				// prints to standard error stream (possible redundancy?)
				//e.printStackTrace();
			}
		}
		// if we were streaming the output to the input, we could:
		// look at PipeWriter/Reader (char based - ie default encoding), or PipedOutputStream, or...
		// see http://hell.org.ua/Docs/oreilly/java/fclass/ch06_02.htm for options
		/
		BufferedReader reader = request.getReader();
		response.setContentType("text/html; charset=UTF-8");
		PrintWriter out = new PrintWriter(new OutputStreamWriter(response.getOutputStream(), "UTF-8"), true);

		// Read and write 4K chars at a time
		// (Far more efficient than reading and writing a line at a time)
		char[] buf = new char[4 * 1024]; // 4Kchar buffer
		int len;
		while ((len = reader.read(buf, 0, buf.length)) != -1) {
			out.write(buf, 0, len);
		}
		out.flush();
		/
	}
	*/
	// this is redundant since if we print(encode(...)) the print() will convert it back to a string then use the default encoding to write it out
	// also see a faster way - http://mindprod.com/jgloss/encoding.html
	/*
	protected byte[] encode(String str) throws UnsupportedEncodingException {
		CharBuffer ss = CharBuffer.wrap(str);
		ByteBuffer bb = utf8.encode(ss);
		int limit = bb.limit();
		byte[] b = new byte[limit];
		bb.get(b, 0, limit);
		return b;

		// another alternative - simple but slowish - has to lookup the utf-8 each time
		//byte[] b = str.getBytes("UTF-8");
		//return b;
	}
	*/
}
